package org.texteasy.sms;

import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;

import org.texteasy.sms.SMSConstants.SMSHelper;
import org.texteasy.sms.SMSConstants.ThreadsColumns;

import android.content.ContentUris;
import android.content.Context;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.drawable.BitmapDrawable;
import android.net.Uri;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Looper;
import android.provider.ContactsContract.Contacts;
import android.provider.ContactsContract.Data;
import android.provider.ContactsContract.CommonDataKinds.Email;
import android.provider.ContactsContract.CommonDataKinds.Phone;
import android.telephony.PhoneNumberUtils;
import android.util.Log;

public final class RecipientCache {

	private static final int QUERY_THREAD_CACHE_UPDATE_COUNT = 100;
	private static final int QUERY_THREAD_AVATAR_UPDATE_COUNT = 20;
	
	private static final int FROM_COLUMN_INDEX = 3;
	private static final int SUBJECT_COLUMN_INDEX = 4;
	private static final int DATE_COLUMN_INDEX = 1;
	
	private long queryDate;
	//Maps a recipientId to a name, or if no name can be found a number/email address
	//Don't need to synchronzie access to the map. The query thread simply replaces the map
	//each time it does an update, it doesn't update the map. 
	private HashMap<String,Contact> recipientIdToIdentifierMap = new HashMap<String, Contact>();
	
	//maps an address (phonenumber or email address) to a name
	//Don't need to synchronzie access to the map. The query thread simply replaces the map
	//each time it does an update, it doesn't update the map. 
	private HashMap<String,Contact> addressToIdentifierMap = new HashMap<String, Contact>();
	
	private final Object listenerLock = new Object();
	//synchronize any access to listeners using the listenerLock 
	private final HashSet<UpdateListener> listeners = new HashSet<UpdateListener>();
	public interface UpdateListener {
        public void onRecipientCacheUpdated();
    }
	
	private final Handler queryHandler;
	private final Looper queryLooper;
	
	public RecipientCache(){
		this(new Date());
	}
	
	public RecipientCache(Date queryDate){
		if(queryDate == null){
			throw new IllegalArgumentException("This method does not accept null parameters");
		}
		
		this.queryDate = queryDate.getTime();
		this.recipientIdToIdentifierMap = new HashMap<String, Contact>();
		
		HandlerThread  thread = new HandlerThread(getClass().getSimpleName());
		thread.start();
		
		this.queryLooper = thread.getLooper();
		this.queryHandler = new RecipientQueryHandler(this.queryLooper);
	}
	
	public ArrayList<Contact> getContactsByRecipientIds(String[] recipientIds){
		if(recipientIds == null){
			throw new IllegalArgumentException("This method does not " +
				"accept null parameters.");
		}
		
		ArrayList<Contact> toReturn = new ArrayList<Contact>();
		//get our own reference to the map as the map could be replaced while iterating over recipientIds  
		HashMap<String, Contact> recipientIdToContactMap = this.recipientIdToIdentifierMap;
		for(String recipientId: recipientIds){
			Contact contact = recipientIdToContactMap.get(recipientId);
			if(contact != null){
				toReturn.add(contact);
			}else{
				Log.w(getClass().getSimpleName(), "RecipientId cache miss when looking " +
					"up recipientId: " + recipientId);
			}
		}
		
		return toReturn;
	}
	
	/**
	 * Returns the contact associated with the specified address or null if no
	 * association can be found.
	 * @param address
	 * @return
	 */
	public Contact getContactByAddress(String address){
		
		if(address == null){
			return null;
		}
		
		HashMap<String, Contact> addressToIdMap = addressToIdentifierMap;
		
		for(String tempAddress: addressToIdMap.keySet()){
			if(PhoneNumberUtils.compare(tempAddress, address)){
				return addressToIdMap.get(tempAddress);
			}
		}
		
		return null;
	}
	
	/**
	 * Thread safe method. The newRecipientCache is optional
	 * @param newRecipientCache
	 */
	private void updateRecipientCache(HashMap<String, Contact> newRecipientCache, 
			HashMap<String,Contact> newAddressCache){
		
		if(newRecipientCache == null){
			throw new IllegalArgumentException("This method does not accept null parameters");
		}
	
		recipientIdToIdentifierMap = newRecipientCache;
		
		if(newAddressCache != null){
			addressToIdentifierMap = newAddressCache;
		}
		
		synchronized (listenerLock) {
			for(UpdateListener listener: listeners){
				listener.onRecipientCacheUpdated();
			}
		}
	}
	
	
	/**
	 * Only call this method from the main UI thread
	 * @param l
	 */
	public void addListener(UpdateListener l) {
		synchronized (listenerLock) {
			listeners.add(l);
		}
    }

	/**
	 * Only call this method from the main UI thread
	 * @param l
	 */
    public void removeListener(UpdateListener l) {
    	synchronized (listenerLock) {
    		listeners.remove(l);
		}
    }
	
    public long getQueryDate(){
    	return this.queryDate;
    }
    
    public void onDestroy(){
    	this.queryLooper.quit();
    }
    
	/**
	 * Initializes or reinitializeds the cache. The cache will be filled by querying 
	 * sms conversation threads using the given date, or if date is null then the
	 * date set in the contructor.
	 * @param queryDate Can be null
	 */
	public void init(final Date queryDate, final Context context){
		
		if(queryDate != null){
			this.queryDate = queryDate.getTime();
		}
		
		
		this.queryHandler.post(new Runnable(){
			
			@Override
			public void run() {
				
				Log.i(getClass().getSimpleName(), "Update RecipientCache starting");
				
				Cursor cursor = null;
				ArrayList<Long> recipientIds = new ArrayList<Long>();
				try{
					cursor = context.getContentResolver().query(
						SMSConstants.allThreadsUri, SMSConstants.smsHistoryProjection, 
						ThreadsColumns.DATE + " <= ?", new String[]{ Long.toString(RecipientCache.this.queryDate)},
						SMSConstants.DEFAULT_SORT_ORDER);
					
					
					while(cursor.moveToNext()){
						
						String recipientIdsString = cursor.getString(FROM_COLUMN_INDEX);
						
						for(String recipientId: recipientIdsString.split(" ")){
							recipientIds.add(new Long(recipientId));
						}
						
					}
				}finally{
					if(cursor != null){
						cursor.close();
					}
				}
				
				//try{Thread.sleep(5000);}catch(InterruptedException ex){}
				
				//now lookup the phoneNumbers/email addresses of the recipentIds
				HashMap<String, Contact> recipientIdsMap = new HashMap<String, Contact>();
				try{
					cursor = context.getContentResolver().query(
							SMSConstants.allCanonical, null, 
							null, null, null);
					
					 while (cursor.moveToNext()) {
					
						 long id = cursor.getLong(0);
						 if(recipientIds.contains(id)){
							 String number = cursor.getString(1);
							 Contact contact = new Contact();
							 contact.setDisplayValue(number);
							 recipientIdsMap.put(Long.toString(id) , contact);
						 }
					 }
					
				}finally{
					if(cursor != null){
						cursor.close();
					}
				}
				
				//ok got some initial data for the recipientIdMap, update the cache so any interested 
				//threads can get the info
				updateRecipientCache(new HashMap<String, Contact>(recipientIdsMap), null);
				
				HashMap<String,Contact> addressesMap = new HashMap<String, Contact>();
				
				//try to find contacts that are associated with the numbers/email addresses
				int count = 0;
				for(String recipientId: recipientIdsMap.keySet()){
					
					Contact contact = recipientIdsMap.get(recipientId);
					try{
						String numberOrEmail = contact.getDisplayValue();
						if(SMSHelper.isPhoneNumber(numberOrEmail)){
							Uri uri = Uri.withAppendedPath(Phone.CONTENT_FILTER_URI, 
								Uri.encode(numberOrEmail));
							cursor = context.getContentResolver().query(uri, new String[]{
									Phone.DISPLAY_NAME, Phone.CONTACT_ID}, 
									null, null, null);
							
							if(cursor.moveToFirst()){
								String displayName = cursor.getString(0);
								contact.setDisplayValue(displayName);
								contact.setContactId(cursor.getLong(1));
								addressesMap.put(numberOrEmail, contact);
							}
						}else{
							cursor = context.getContentResolver().query(Data.CONTENT_URI, new String[]{Data.DISPLAY_NAME, Data.CONTACT_ID}, 
									"UPPER(" + Email.DATA + ")=UPPER(?) AND " + Data.MIMETYPE + "='" + 
									Email.CONTENT_ITEM_TYPE + "'", new String[]{numberOrEmail}, null);
							
							if(cursor.moveToFirst()){
								String displayName = cursor.getString(0);
								contact.setDisplayValue(displayName);
								contact.setContactId(cursor.getLong(1));
								addressesMap.put(numberOrEmail, contact);
							}
						}
					}finally{
						if(cursor != null){
							cursor.close();
						}
					}
					
					//update recipientCache every QUERY_THREAD_CACHE_UPDATE_COUNT recipientId
					//While unlikely it is possible for a user to have a lot of text message 
					//threads (thousands). Don't need to wait for all the recipientIds to be 
					//linked to contacts before showing contactIds, can do it iteratively 
					if( (count % QUERY_THREAD_CACHE_UPDATE_COUNT) ==0 ){
						//try{Thread.sleep(3000);}catch(InterruptedException ex){}
						updateRecipientCache(new HashMap<String, Contact>(recipientIdsMap),
							new HashMap<String, Contact>(addressesMap));
					}
					
					count++;
				}
				
				//do one last update 
				updateRecipientCache(new HashMap<String, Contact>(recipientIdsMap),
						new HashMap<String, Contact>(addressesMap));
				
				//now load the avatar data
				count = 0;
				for(String recipientId: recipientIdsMap.keySet()){
					Contact contact = recipientIdsMap.get(recipientId);
					if(contact.getContactId() != null){
						byte [] data = null;
						Uri contactUri = ContentUris.withAppendedId(Contacts.CONTENT_URI, contact.getContactId());
						InputStream avatarDataStream = Contacts.openContactPhotoInputStream(
			                    context.getContentResolver(),
			                    contactUri);
						try {
			                if (avatarDataStream != null) {
			                    data = new byte[avatarDataStream.available()];
			                    avatarDataStream.read(data, 0, data.length);
			                }
			            } catch (IOException ex) {
			                //
			            } finally {
			                try {
			                    if (avatarDataStream != null) {
			                        avatarDataStream.close();
			                    }
			                } catch (IOException e) {
			                }
			            }
			            
			            if(data != null){
			            	Bitmap b = BitmapFactory.decodeByteArray(data, 0, data.length);
			            	contact.setAvatar(new BitmapDrawable(context.getResources(), b));
			            }
			            
			            //just updated avatar data so let the listeners know
			            if( (count % QUERY_THREAD_AVATAR_UPDATE_COUNT) ==0 ){
			            	synchronized (listenerLock) {
			        			for(UpdateListener listener: listeners){
			        				listener.onRecipientCacheUpdated();
			        			}
			        		}
			            }
					}
				}
				
				//do one last notification
				synchronized (listenerLock) {
        			for(UpdateListener listener: listeners){
        				listener.onRecipientCacheUpdated();
        			}
        		}
				
				Log.i(getClass().getSimpleName(), "Update RecipientCache complete");
			}
		});
		
	}
	
	private static class RecipientQueryHandler extends Handler{
		
		public RecipientQueryHandler(Looper looper){
			super(looper);
		}
	}
}
